package domain;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.ConnectException;
import java.net.InetAddress;
import java.net.Socket;
import java.net.UnknownHostException;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * The client, the heart of the application
 *
 * @author Joachim
 */
public class Client {

    private static Client instance; // singleton pattern

    private static InetAddress host; // server address
    private static final int PORT = 1337; // port the application runs on
    private Socket link; // connection with the server

    private static final int BUFFERSIZE = 1024;

    private DataInputStream socketIn; // receive from server
    private DataOutputStream socketOut; // send to server

    private DirectoryListener listener;
    private boolean performingInitialSync; // true if the client is busy with the initial sync

    private Settings settings;
    private File dir; // file object of the root directory

    /**
     * Singleton pattern
     *
     * @return Returns the client
     */
    public static Client getInstance() {
        if (instance == null) {
            instance = new Client();
        }
        return instance;
    }

    /**
     * Default constructor: <br />
     * - Initializes the host. <br />
     * - Initializes the directory listener & starts the thread <br />
     * - Initializes the settings <br />
     * - Sets the current synchronizing state to false
     */
    private Client() {
        try {
            host = InetAddress.getLocalHost();
        } catch (UnknownHostException e) {
            System.exit(1);
        }

        listener = new DirectoryListener(this);
        listener.start();

        settings = new Settings();
        if(!settings.getRootDirectory().isEmpty()) {
            try {
                // Try setting the root directory this will do 2 things:
                // - Check if the root directory from the settings file still exists
                // - Register the root directory with the directory listener:
                //     Normally the root directory is registered with the listener when it is 'set' from the GUI.
                //     If you load the root directory from the settings file, the GUI never 'sets' the root directory so the listener won't register it.
                // If something goes wrong just set the root directory to empty so the user will need to re-select his root directory
                setRootDirectory(settings.getRootDirectory());
            }
            catch(Exception e) {
                settings.setRootDirectory("");
                settings.saveSettings();
            }
        }
        performingInitialSync = false;
    }

    /**
     * Returns the root directory from the settings
     *
     * @return The root directory
     */
    public String getRootDirectory() {
        return settings.getRootDirectory();
    }

    /**
     * Saves the root directory of the user in a settings file. <br :>
     * Registers the root directory with the directory listener.
     *
     * @param rootDirectory The root directory of the user
     * @throws InvalidPathException When the rootdirectory doesn't exist or when
     * it's not a folder
     * @throws IOException When the directory listener can't register the root
     * directory
     */
    public void setRootDirectory(String rootDirectory) throws InvalidPathException, IOException {
        File file = new File(rootDirectory);
        if (!file.exists()) {
            throw new InvalidPathException(rootDirectory, "Invalid directory path!");
        }
        if (!file.isDirectory()) {
            throw new InvalidPathException(rootDirectory, "Not a directory!");
        }
        listener.registerDirectoryAndSubDirectories(rootDirectory);
        settings.setRootDirectory(rootDirectory);
        settings.saveSettings();
    }

    /**
     * Returns the email address from the settings
     *
     * @return The email address
     */
    public String getEmail() {
        return settings.getEmail();
    }

    /**
     * Activates the directory listener: Start watching for changes.
     */
    public void activateDirectoryListener() {
        listener.setActivated(true);
    }

    /**
     * Deactivates the directory listener: Stop watching for changes.
     */
    public void deactivateDirectoryListener() {
        listener.setActivated(false);
    }

    /**
     * Returns whether the client is performing its initial sync or not
     *
     * @return True when the initial sync is being performed
     */
    public boolean isPerformingInitialSync() {
        return performingInitialSync;
    }

    /**
     * Opens the connection with the server.
     *
     * @throws ConnectException If connection with the server fails.
     */
    private void initConnection() throws ConnectException {
        try {
            link = new Socket(host, PORT);
            socketIn = new DataInputStream(link.getInputStream());
            socketOut = new DataOutputStream(link.getOutputStream());
        } catch (IOException ioe) {
            throw new ConnectException("Connection with server failed");
        }
    }

    /**
     * Closes the connection with the server
     */
    public void closeConnection() {
        try {
            socketOut.writeUTF("closeConnection");
            link.close();
        } catch (IOException ioe) {
            // Exception -> connection is already closed so do nothing
        }
    }

    /**
     * This method will: <br />
     * - Validate the user input <br />
     * - Open a connection with the server <br />
     * - Try to log in the user on the server <br />
     * - Close the connection with the server if log in fails
     *
     * @param email The email address of the user
     * @param password The password of the user
     * @return True or false to show whether log in was successful or not.
     * @throws IllegalArgumentException If validation fails (empty fields,
     * invalid email).
     * @throws ConnectException If connection with the server fails.
     */
    public boolean logIn(String email, String password) throws IllegalArgumentException, ConnectException {
        if (email.isEmpty() || password.isEmpty()) {
            throw new IllegalArgumentException("All fields are required");
        } else if (!Utils.isValidEmail(email)) {
            throw new IllegalArgumentException("Email is not valid");
        }
        initConnection(); // Open connection with server
        Boolean loginSuccess = false;
        try {
            socketOut.writeUTF("login");
            socketOut.writeUTF(email);
            socketOut.writeUTF(password);
            loginSuccess = socketIn.readBoolean();
        } catch (IOException ioe) {
            throw new ConnectException("Connection with server failed");
        }
        if (loginSuccess) {
            settings.setEmail(email);
            settings.saveSettings();
        } else {
            closeConnection();
        }
        return loginSuccess;
    }

    /**
     * This method will: <br />
     * - Validate the user input <br />
     * - Open a connection with the server <br />
     * - Try to register a user on the server <br />
     * - Close the connection with the server
     *
     * @param email The email of the user
     * @param password The password of the user
     * @param repeatPassword The repeated password of the user
     * @return True or false to show whether registration was successful or not.
     * @throws IllegalArgumentException If validation fails (empty fields,
     * invalid email, passwords don't match).
     * @throws ConnectException If connection with the server fails.
     */
    public boolean register(String email, String password, String repeatPassword) throws IllegalArgumentException, ConnectException {
        if (email.isEmpty() || password.isEmpty() || repeatPassword.isEmpty()) {
            throw new IllegalArgumentException("All fields are required");
        } else if (!Utils.isValidEmail(email)) {
            throw new IllegalArgumentException("Email is not valid");
        } else if (!password.equals(repeatPassword)) {
            throw new IllegalArgumentException("Passwords don't match");
        }
        initConnection(); // Open connection with server
        Boolean registerSuccess = false;
        try {
            socketOut.writeUTF("register");
            socketOut.writeUTF(email);
            socketOut.writeUTF(password);
            registerSuccess = socketIn.readBoolean();
        } catch (IOException ioe) {
            throw new ConnectException("Connection with server failed");
        }
        closeConnection();
        return registerSuccess;
    }

    /**
     * This method will: <br />
     * - Validate the user input <br />
     * - Open a connection with the server <br />
     * - Try to reset a users password <br />
     * - Close the connection with the server
     *
     * @param email The email of the user
     * @return True or false to show whether registration was successful or not.
     * @throws IllegalArgumentException If validation fails (invalid email).
     * @throws ConnectException If connection with the server fails.
     */
    public boolean resetPassword(String email) throws IllegalArgumentException, ConnectException {
        if (!Utils.isValidEmail(email)) {
            throw new IllegalArgumentException("Email is not valid");
        }
        initConnection(); // Open connection with server
        Boolean resetPasswordSuccess = false;
        try {
            socketOut.writeUTF("resetPassword");
            socketOut.writeUTF(email);
            resetPasswordSuccess = socketIn.readBoolean();
        } catch (IOException e) {
            throw new ConnectException("Connection with server failed");
        }
        closeConnection();
        return resetPasswordSuccess;
    }

    /**
     * Uploads a file to the server
     *
     * @param relativePath The path to the file, relative to the root directory
     */
    public void uploadFileToServer(String relativePath) {
        try {
            File file = new File(settings.getRootDirectory(), relativePath);
            int fileSize = (int) file.length();

            socketOut.writeUTF("acceptFile");
            socketOut.writeUTF(dir.toURI().relativize(file.toURI()).getPath()); // Send file name
            socketOut.writeInt(fileSize); // Send file size

            int n;
            byte[] buffer = new byte[BUFFERSIZE];
            byte[] bufferS;
            DataInputStream fileIn = new DataInputStream(new FileInputStream(file));
            while ((n = fileIn.read(buffer)) > 0) {
                bufferS = new byte[n];
                System.arraycopy(buffer, 0, bufferS, 0, n);
                socketOut.write(bufferS, 0, n);
                //socketOut.flush();
            }
            fileIn.close();
            socketOut.flush();
        } catch (IOException ex) {
            Logger.getLogger(Client.class.getName()).log(Level.SEVERE, null, ex);
        }
    }

    /**
     * Deletes a file on the server
     *
     * @param relativePath The path to the file, relative to the root directory
     */
    public void deleteFileOnServer(String relativePath) {
        try {
            socketOut.writeUTF("deleteFile");
            socketOut.writeUTF(relativePath);
        } catch (IOException ex) {
            Logger.getLogger(Client.class.getName()).log(Level.SEVERE, null, ex);
        }
    }

    /**
     * Downloads a file from the server to the root directory
     */
    public void downloadFileFromServer() {
        try {
            String relativePath = socketIn.readUTF(); // Receive file name 
            int fileSize = socketIn.readInt(); // Receive file size

            File f = new File(settings.getRootDirectory(), relativePath);
            File d = new File(f.getParent());
            d.mkdirs();
            DataOutputStream fileOut = new DataOutputStream(new FileOutputStream(f));

            int n, totalSize = 0;
            byte[] buffer = new byte[BUFFERSIZE];
            while (!(totalSize == fileSize)) {
                //System.gc();
                n = BUFFERSIZE;
                totalSize += n;
                if (totalSize > fileSize) {
                    n -= totalSize - fileSize;
                    totalSize = fileSize;
                }
                buffer = new byte[n];
                socketIn.readFully(buffer);
                fileOut.write(buffer);
                //fileOut.flush();
            }
            fileOut.flush();
            fileOut.close();
        } catch (IOException ex) {
            ex.printStackTrace();
        }
    }

    /**
     * Perform the initial sync, lock the directorylistener while doing this.
     *
     * @throws ConnectException If connection with the server fails.
     */
    public void performInitialSync() throws ConnectException {
        performingInitialSync = true;

        File dirp = new File(getRootDirectory());
        dir = dirp;
        dir.mkdir();

        HashMap<String, File> fileMap = Utils.getFileMap(dir);

        try {
            socketOut.writeUTF("synchronize");

            String type = socketIn.readUTF();
            String relatiefPath;
            long lastModified = 0;
            byte[] hash = new byte[20];

            while (!type.equals("end")) {
                relatiefPath = socketIn.readUTF();
                lastModified = socketIn.readLong();
                socketIn.readFully(hash);

                if (fileMap.containsKey(relatiefPath)) {
                    if (Arrays.equals(Utils.calculateFileHash(fileMap.get(relatiefPath)), hash)) {
                        socketOut.writeUTF("synched");
                    } else {
                        if (lastModified < Files.getLastModifiedTime(fileMap.get(relatiefPath).toPath()).toMillis()) {
                            socketOut.writeUTF("changed_client");
                            uploadFileToServer(relatiefPath);
                        } else {
                            socketOut.writeUTF("changed_server");
                            if (socketIn.readUTF().equals("acceptFile")) {
                                downloadFileFromServer();
                            }
                        }
                    }
                    fileMap.remove(relatiefPath);
                } else {
                    socketOut.writeUTF("notfound");
                    if (socketIn.readUTF().equals("acceptFile")) {
                        downloadFileFromServer();
                    }
                }
                type = socketIn.readUTF();
            }
            for (String nieuw : fileMap.keySet()) {
                uploadFileToServer(nieuw);
            }
            socketOut.writeUTF("end");
        } catch (IOException ex) {
            Logger.getLogger(Client.class.getName()).log(Level.SEVERE, null, ex);
        }
        performingInitialSync = false;
    }

}
